数据库 SRE:备份没演练过,就等于没有备份 原创
本文是 SRE 实战系列第 23 篇,第二循环第二组第二篇。
前面二十二篇有一个共同的前提:服务是无状态的。Pod 挂了拉起来,副本扩了接流量,版本回滚了就当刚才那半小时没发生。第 19 篇里那句"回滚不是撤销键",指的就是这个前提失效的地方——已经写进数据库的那一行数据,回滚不回来。
这篇把那个角落展开。全文约 1.1 万字。从一次 5 小时 40 分的数据事故开头:影响面 12 万行,搬数据(prepare、restore、重放 binlog、回写)占了 210 分钟,而准备占了 80 分钟——找一份能用的备份、开一台规格对的机器。后面这 80 分钟,恰好是演练能提前消掉的。
然后回答七个问题:备份怎么才算存在、恢复要多久才能算承诺、单表怎么回写、主从延迟多少叫可容忍、大表 DDL 什么时候做、连接池怎么算得上、以及为什么数据事故里最贵的往往不是数据。
这一篇的立场:备份不是一份文件,是一条被验证过的路径。没走通过的路,不能算路。
一、5 小时 40 分:210 分钟搬数据,80 分钟找机器
一个 SaaS 平台的订单库,MySQL 8.0 一主两从,主库 800GB。备份策略写在手册里,很标准:每天 02:00 xtrabackup 全量,binlog 保留 7 天,备份落在一台独立备份机上。RTO 承诺:1 小时。
周三 15:20,一个数据修复脚本被执行。脚本本身经过 SQL 平台审核,审核人看到的是"按条件修正金额",条件里少了一个 order_status。12 万行被改错。
事后拉的时间线:
| 时间 | 发生了什么 | 耗时 |
|---|---|---|
| 15:20 | 修复脚本执行完成 | — |
| 15:26 | 客服反馈"部分订单金额不对" | 发现 6 分钟 |
| 15:41 | 定位到 121,438 行受影响,涉及 3 天内的订单 | 定位 15 分钟 |
| 15:50 | 决定恢复数据 | 决策 9 分钟 |
| 16:05 | 翻备份目录:当天 02:00 那份只有 3.7GB | 15 分钟 |
| 16:20 | 查明原因:备份盘 inode 用满,mysqldump 写到一半失败,但脚本退出码是 0 | 15 分钟 |
| 16:40 | 锁定前天 02:00 的备份,800GB,物理备份 | 20 分钟 |
| 17:10 | 开临时实例:规格对不上,重开一台 | 30 分钟 |
| 17:50 | --prepare 完成 | 40 分钟 |
| 19:10 | restore 到临时实例完成 | 80 分钟 |
| 20:20 | binlog 重放 02:00 → 15:20(13 小时 20 分) | 70 分钟 |
| 20:40 | 校验:行数、金额汇总、主键范围 | 20 分钟 |
| 21:00 | 12 万行按主键分批回写生产 | 20 分钟 |
| 21:05 | 业务确认金额恢复,事故结束 | — |
5 小时 40 分。这个数字最刺眼的地方不是它长,而是它的构成:
- 真正"搬数据"的时间:40(prepare)+ 80(restore)+ 70(重放)+ 20(回写)= 210 分钟
- 真正"数据以外"的时间:15(翻备份)+ 15(查为什么备份坏了)+ 20(定哪份备份)+ 30(准备机器)= 80 分钟
- 现场判断的时间:50 分钟(发现 6、定位 15、决策 9、校验 20)
如果当天的备份是好的、如果恢复实例是预置好的、如果事先就知道 prepare 要 40 分钟,这次事故是 4 小时出头。省下的那 80 分钟,跟 DBA 的技术水平一点关系都没有,只跟有没有走过这条路有关。
更扎心的是事后的核对:RTO 承诺 1 小时,而"800GB 全量恢复 + 13 小时 binlog 重放"这条路,在最优情况下也要 3 小时。 也就是说,这份承诺从写下的那天起就是假的,只是没人算过。
二、数据层和前面二十几篇有四个不同
把数据层的问题硬套到无状态服务的方法论上,会得出完全错误的结论。差别有四处,每一处都对应一个具体的工程动作。
第一,不可逆。 第 19 篇讲回滚,讲得很清楚:镜像、配置、flag、迁移版本都能回去,已写入的数据不能。回滚能把代码恢复到 v2.31.2,但回滚不了它写进去的 12 万行。所以数据层没有"回滚",只有"补偿"和"重建"。
第二,恢复的粒度是一个陷阱。 服务出问题,恢复单位是"一个服务"。数据出问题,受影响的是"一张表里的 12 万行",而备份的恢复单位是"整个实例 800GB"。这两者之间的换算,就是那 5 小时 40 分的由来:为了修 12 万行,得先让 800GB 重新站起来一次。
第三,恢复本身就是一次最危险的变更。 恢复要开机器、要导数据、要回写生产。它同时踩中了第 19 篇的 C3 全部条件(Schema 相关、跨服务契约风险、资源规格变动),而且是在业务已经受损、所有人都在看的时候做。第 22 篇里 IC 最难拍板的决定,一半来自数据层的这类操作——因为它们不可逆、涉及跨团队(DBA / 财务 / 法务)、恢复时长远超技术恢复。
第四,时间尺度不同。 应用层事故的止损是分钟级(第 8 篇的 5 分钟),数据层事故的"止损"是小时级,而"完全恢复"可能是天级。用分钟级的应急范式去管小时级的恢复,结果就是全程都在做错误的优先级排序。
所以这一篇的地基,是那条被拉长的时间线:
MTTR_data = T_detect + T_locate + T_provision + T_restore + T_replay + T_validate + T_writeback每一项都有自己的量级和削法:
| 项 | 含义 | 典型量级 | 怎么压 |
|---|---|---|---|
T_detect | 从数据被改错到有人知道 | 5~30 分钟 | 数据校验任务(业务口径的日/小时对账),不靠人发现 |
T_locate | 确认影响范围与时间点 | 10~30 分钟 | 行级审计、binlog 反解先跑一遍(见第七节) |
T_provision | 准备实例、磁盘、账号、网络 | 20~60 分钟 | 预置一台常备的恢复实例;演练时把清单固定下来 |
T_restore | 备份 restore 到临时实例 | D / R | 换物理备份、换更快的盘、用 Clone 插件 |
T_replay | binlog 重放到目标时间点 | 与 binlog 时长成正比 | 并行重放、缩短"最近一次全量"的间隔 |
T_validate | 校验数据对不对 | 10~30 分钟 | 金丝雀查询脚本化(见第五节) |
T_writeback | 把恢复出来的数据写回生产 | 与行数成正比 | 按主键分批、可中断、可重入 |
两个公式值得单独记住,因为它们决定了你能承诺什么:
T_restore ≈ D / RD 是数据量,R 是恢复吞吐。物理备份(文件级 copy + prepare)的 R 在 100~250MB/s;逻辑备份(mysqldump 出来的 SQL 文件)的 R 只有 5~20MB/s,因为它是单线程执行 INSERT。
800GB 用逻辑备份恢复,需要 11 到 44 小时。 这个数字一算出来,"RTO 1 小时"当场破功。备份方式的选择不是运维偏好,它直接给 RTO 设了下限——先定 RTO,再选备份方式,顺序反了就是自欺。
T_replay ≈ binlog 时长 / s, s ≈ 5~15s 是重放倍速。binlog_format=ROW 时,从库的 SQL 线程直接应用行事件,不需要 SQL 解析、不需要网络往返、不需要走应用层——同样的写入量,重放通常比原始在线写入快 5~15 倍。第一节那 13 小时 20 分的 binlog 重放用了 70 分钟,s ≈ 11,是个正常值。
但 s 有个陡坡:没有主键的表。ROW 事件在从库上要定位到具体行,没主键就只能全表扫,s 会掉到 1 以下——越重放越慢,延迟还在涨,这时候重放本身就成了事故。所以"所有表必须有主键"不是风格问题,它是恢复能力的一部分。
按 s = 10 估,"每天 02:00 全量"的最坏情况是重放 24 小时 binlog,约 2.4 小时。想让这个数变小只有两条路:把全量做得更频繁(每 6 小时一次增量 + binlog),或清掉无主键表。祈祷重放快一点不算办法。
三、备份骗人的六种方式
第一节那个案例的可悲之处在于:备份策略是有文档的,监控是有的,任务每天在跑,退出码是 0。
备份失败几乎从来不是"忘了配备份",而是配了、跑了、看着成功,实际不可用。下面是六种最常见的形状,每一种都在真实环境里出现过很多次。
1. 脚本退出码是 0,但备份是坏的
# 反面写法:管道让退出码变成最后一个命令的
mysqldump --all-databases | gzip > /backup/full.sql.gz
echo "backup done: $?" # 永远是 0,因为 gzip 成功了mysqldump 失败(磁盘满、连接断、权限不足)时,gzip 依然会成功退出,管道的退出码取最后一个命令。正确的做法是 set -o pipefail,加上对产物体积的合理性校验(见第五节)。同类坑还有 || true、& 后台执行、cron 里 >/dev/null 2>&1 把错误吞掉。
2. 备份成功,但从来没 prepare 过
xtrabackup 的备份目录不是"拷完就能用"的。物理备份必须 --prepare 一次(redo 回放,让数据文件进入一致状态),增量备份还要按顺序 --apply-log-only 逐个合并。很多人只在文档里见过 prepare,第一次真跑是在事故现场。第一节那 40 分钟就是这么来的。
3. 备份存在,但和数据库在同一块盘上
备份路径写的是 /backup,用的是同一块云盘挂载点,或者同一台机器上的本地盘。机器挂了、盘打满了、rm -rf 敲错了,备份和数据库一起没。备份的第一个要求是物理隔离(不同机器 / 不同可用区),第二个要求是逻辑隔离(备份账号没有删备份的权限)。
4. binlog 断了,而没人知道
全量能恢复,binlog 能重放,前提是两者能接上。断的方式有很多:主从切换后 binlog 文件名变了、expire_logs_days 太短被 purge、重启把 binlog_expire_logs_seconds 改了、RESET MASTER 清过。表现是恢复时重放到某个点就报 Could not find first log file name in binary log index file。
这条最容易被忽略,因为它平时毫无症状:复制正常、监控全绿,只有在需要重放的那天才发现链断了。所以它必须做成日检(见第五节)。
5. 备份在,但恢复不出来
加密密钥丢了、备份账号被回收、S3 生命周期策略把 30 天前的对象转成了 Glacier 取回要 5 小时、备份文件属主是已经离职的人的 UID。这些都不影响"备份存在"这个事实,只影响"能不能用"。
6. 恢复出来了,能启动,业务不能用
最隐蔽的一种。mysqldump 默认导出不含存储过程和事件(需要显式 --routines --events),触发器默认包含但要 --triggers;--set-gtid-purged 设错会让导入报 GTID 冲突;字符集不带 --default-character-set=utf8mb4 会让中文变问号;DEFINER 指向一个不存在的账号会让视图和存储过程创建失败。数据库起来了,SELECT 1 正常,业务一查就崩。
六种合起来是一条:"备份存在"和"备份可用"之间,隔着一次真实的恢复。
四、四种备份方式的真实边界
选型不看喜好,看三个约束:RPO(能丢多少数据)、RTO(多久必须恢复)、D(数据量)。三者的组合直接排除掉一半选项。
| 方式 | 粒度 | 对主库影响 | 恢复速度 R | 能否 PITR | 适合 |
|---|---|---|---|---|---|
mysqldump / mysqlpump | 库 / 表 / 行(--where) | 中(长事务 + 一致性快照,大表时 undo 膨胀) | 5~20MB/s(单线程 INSERT) | 需配 binlog | D < 50GB;跨大版本迁移;单表导出 |
xtrabackup / PXB | 实例级(可单库单表导出) | 低(物理拷贝,无锁) | 100~250MB/s | 需配 binlog | D > 50GB 的常规选择 |
| 文件系统 / 云盘快照 | 卷级 | 极低 | 取决于盘(可达 GB/s) | 需配 binlog | 云上大库;配合 Clone 插件做秒级 provisioning |
| 云托管自动备份 + PITR | 实例级 | 无 | 中 | ✅ 原生支持 | 托管实例;但要验证"最近可恢复时间"滞后 |
三个容易踩的边界:
托管实例的 PITR,不是"恢复到任意一秒"。
云厂商的"可恢复时间点"取决于 binlog 备份上传的节奏,普遍滞后 5 分钟到 1 小时。也就是说:
最晚可恢复时间 = 现在 - 上传滞后(5~60min)15:20 写错的数据,如果上传滞后 40 分钟,你最多只能恢复到 14:40,剩下的 40 分钟要靠 binlog 反解逐条补。这一条必须写进 RPO 承诺里,否则承诺的 RPO 是 0,实际的 RPO 是 40 分钟。日检脚本里必须有一项就是查这个滞后(第五节)。
物理备份不能跨大版本。 MySQL 5.7 物理备份不能直接恢复到 8.0 实例,--prepare 就会失败。跨版本只有逻辑备份一条路,代价是恢复时间按 10 倍涨——所以大版本升级必须顺带规划"数据量对应的逻辑恢复窗口",这在第 19 篇的 C4 变更(不可逆、需业务低峰窗口)里是最容易被低估的一项。
快照快,但一致性要自己保证。 卷级快照在崩溃一致性上是"相当于拔电源"——对于 InnoDB 通常能靠 redo 恢复,但如果同一实例上有 MyISAM 表、有非事务表、有正在跑的 DDL,快照可能直接不可用。要么 fsync + 短暂 fsfreeze,要么干脆用 PXB。
五、备份验证:从"文件存在"到"能查回来"
验证分三级,成本差一个量级,能挡的风险也差一个量级。多数团队只做到第 0 级。
| 级别 | 做什么 | 频率 | 成本 | 能挡住什么 |
|---|---|---|---|---|
| L0 存在性 | 文件在、大小合理、校验和匹配 | 每天 | 秒级 | 第三节的第 1、3 种 |
| L1 可恢复性 | 真的恢复到临时实例,能 SELECT 1,CHECK TABLE 通过 | 每周 | 二十分钟到两小时 | 第 2、4、5 种 |
| L2 业务可用性 | 金丝雀查询:行数、聚合值、外键、字符集 | 每周 + 每次演练 | 加十分钟 | 第 6 种 |
5.1 每天跑的存在性审计
#!/usr/bin/env bash
# backup-audit.sh —— 每天 09:00 跑,检查"备份是否真的存在且能用"
# 输出:JSON 到监控系统 + 非 0 退出码触发告警
set -euo pipefail
BACKUP_DIR=/backup/mysql
# 800GB 的库用 xtrabackup 压缩后约 300~500GB,下限按 250GB 卡;这个数必须按实测量一次再定
MIN_SIZE=$((250 * 1024 * 1024 * 1024))
LOG=/var/log/backup-audit.log
fail() { echo "{\"level\":\"critical\",\"msg\":\"$1\"}" | tee -a "$LOG"; exit 1; }
# 1) 最新一份备份存在且体积合理(挡"退出码 0 但没备份")
# 注意:ls | head 的退出码是 head 的,永远为 0,不能直接用它判断"有没有备份"
[ -n "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ] || fail "backup dir empty: $BACKUP_DIR"
latest=$(ls -1t "$BACKUP_DIR"/full-* 2>/dev/null | head -1)
[ -n "$latest" ] || fail "no full backup found"
size=$(du -sb "$latest" | cut -f1)
[ "$size" -ge "$MIN_SIZE" ] || fail "backup too small: $latest ($size bytes)"
# 2) 备份完成标记(xtrabackup 成功时才会有 xtrabackup_checkpoints 的 backup_type=full-backuped)
grep -q "backup_type = full-backuped" "$latest/xtrabackup_checkpoints" \
|| fail "backup not completed: $latest"
# 3) 新鲜度:超过 26 小时说明昨天的任务没跑成
age_h=$(( ( $(date +%s) - $(stat -c %Y "$latest") ) / 3600 ))
[ "$age_h" -le 26 ] || fail "backup stale: ${age_h}h"
# 4) binlog 链是否连续(挡"能恢复但重放断在中间")
mysql -N -e "SHOW BINARY LOGS" | awk '{print $1}' | sort > /tmp/binlog_now.txt
ls -1 "$BACKUP_DIR"/binlog/ | sort > /tmp/binlog_bak.txt
missing=$(comm -23 /tmp/binlog_now.txt /tmp/binlog_bak.txt | head -5)
[ -z "$missing" ] || fail "binlog missing in backup: $missing"
# 5) 托管实例:PITR 滞后(这一项最常被漏,滞后超 15 分钟就是 RPO 失效)
# 自建实例跳过;托管实例用云 API 取"最晚可恢复时间点",下面以 RDS 为例
if [ "${IS_MANAGED:-0}" = "1" ]; then
latest_restorable=$(aliyun rds DescribeBackups \
--DBInstanceId "$RDS_ID" --BackupMode Automated \
--query 'Items.Backup[0].BackupEndTime' --output tsv 2>/dev/null || true)
[ -n "$latest_restorable" ] || fail "cannot query PITR window"
pitr_lag_h=$(( ( $(date +%s) - $(date -d "$latest_restorable" +%s) ) / 3600 ))
[ "$pitr_lag_h" -le 1 ] || fail "PITR lag ${pitr_lag_h}h, RPO promise broken"
fi
echo "{\"level\":\"info\",\"msg\":\"backup ok\",\"latest\":\"$latest\",\"size\":$size,\"age_h\":$age_h}" | tee -a "$LOG"第 4 项是重点。它每天回答一个问题:"如果要恢复到昨天,链是不是完整的?" 这个问题只有在事故里才会被问到,而那时已经来不及了。
第 5 项只在托管实例上跑。自建实例没有"可恢复时间点"这个概念,它取决于你自己的 binlog 完整性;托管实例则要看云 API(AWS describe-db-instances 的 LatestRestorableTime,阿里云 DescribeBackups 的 BackupEndTime),逻辑一样:now - 最晚可恢复时间点 超过阈值就告警。
5.2 每周的金丝雀查询
L1 只证明"能起来",L2 才证明"能用"。做法是准备一份查询清单,恢复完成后逐条跟生产比对,落在容忍区间内才算通过。
# restore-drill/canary-queries.yaml
# 每条查询:name / sql / tolerance(与生产的偏差容忍度)/ 说明
checks:
- name: 订单表总行数
sql: "SELECT COUNT(*) FROM orders WHERE created_at >= NOW() - INTERVAL 1 HOUR"
tolerance: { type: ratio, value: 0.15 } # 恢复点早于现在,行数应偏少,不超过 15%
- name: 支付金额汇总(当日)
sql: "SELECT COALESCE(SUM(amount),0) FROM payments WHERE created_at >= CURDATE()"
tolerance: { type: ratio, value: 0.05 }
- name: 主键最大值
sql: "SELECT MAX(id) FROM orders"
tolerance: { type: gte, value: 0 } # 只要求不小于生产的历史水位
- name: 字符集抽样
sql: "SELECT COUNT(*) FROM users WHERE nickname REGEXP '[?]{3,}'"
tolerance: { type: lte, value: 0 } # 出现连续问号说明字符集导错了
- name: 外键孤儿行
sql: "SELECT COUNT(*) FROM order_item i LEFT JOIN orders o ON i.order_id=o.id WHERE o.id IS NULL"
tolerance: { type: lte, value: 0 }
- name: 触发器/存储过程数量
sql: "SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema=DATABASE()"
tolerance: { type: eq, value: 14 } # 数量对不上说明 mysqldump 少导了最后两条是最容易被漏的:字符集抽样和前两项业务聚合能挡住第三节第 6 种"能启动但业务不能用",对象数量对比能挡住 --routines --events 没带。这几条的成本几乎为零,但它们在事故里能省掉的正是最难查的半小时。
5.3 一次演练的验收口径
演练不是"恢复成功",而是产出四个实测值。少一个,这个演练就没完成:
| 字段 | 怎么取 | 为什么重要 |
|---|---|---|
rto_actual | 从"宣布开始"到"业务校验通过" | 承诺值的唯一来源,不是估算 |
rpo_actual | 恢复出来数据的最大时间戳 vs 故障时刻 | 告诉你真正会丢多少 |
restore_throughput | D / T_restore | 用来算下次更大数据量的 RTO |
blockers | 卡了多久、卡在哪 | 下次演练要消掉的第一优先级 |
blockers 这一栏比前三个都值钱。第一节那次事故的 80 分钟"数据之外的时间",全部是历史演练里没记录的 blockers:备份盘 inode 用满没人知道、临时实例规格不对、账号没权限读备份目录。第 20 篇 Game Day 的结论在这里同样成立——演练的价值在于它会在一个便宜的下午,把平时看不见的裂缝照出来。
六、单表回写:为 12 万行恢复 800GB 之后的那一步
恢复出来的数据在临时实例上,业务需要的是那 12 万行的正确值回到生产库。这一步看起来最简单,实际最容易出事:直接 INSERT ... SELECT 两张表跨实例跑?网络断一次就得重来;不带主键范围的全量替换?等于把故障期间新写入的正常数据又覆盖一遍。
正确的姿势是三条纪律加一个脚本。
纪律一:先从生产导出"当前版本"。 回写会覆盖生产的现有值,而这些现有值里有故障后新产生的数据。回写之前先给要动的行做一份备份,否则第二次操作失误时连"回到动手前"都没有了。
# 动手前,把要覆盖的行原样留一份(按主键区间,走从库)
mysqldump --single-transaction --no-create-info --skip-triggers \
-h replica.internal order_db order_item \
--where="id BETWEEN 1000000 AND 1121438" \
> /backup/order_item_before_fix_$(date +%Y%m%d%H%M).sql纪律二:按主键区间分页,不用 LIMIT OFFSET。 深分页在 12 万行上不明显,在千万行上会让每一批都扫前面所有行。
纪律三:每批记录行数与 checksum,可中断、可重入。 中断后从最后一个成功区间续跑,而不是从头再来。
真正的数据搬运分两步:先把恢复实例上的目标区间搬进生产的暂存表(不碰原表),再从暂存表逐批覆盖。这样即使中途失败,原表也没被动过。
# 第 1 步:恢复实例 → 生产暂存表(结构一样,表名不同)
mysql -h prod-master.internal order_db -e "
CREATE TABLE IF NOT EXISTS tmp_order_item LIKE order_item; TRUNCATE tmp_order_item;"
mysqldump -h recovery-tmp.internal -u readonly --single-transaction \
--no-create-info --skip-triggers --skip-add-drop-table \
--where="id BETWEEN 1000000 AND 1121438" order_db order_item \
| sed 's/`order_item`/`tmp_order_item`/g' \
| mysql -h prod-master.internal order_db
# 校验搬运完整:行数、主键范围、金额合计三项对齐才继续
mysql -h prod-master.internal order_db -e "
SELECT COUNT(*), MIN(id), MAX(id), SUM(amount) FROM tmp_order_item;"第 2 步,从暂存表按主键区间逐批覆盖原表:
#!/usr/bin/env bash
# writeback.sh —— 从生产的暂存表按主键区间回写目标表,可中断可重入
# 用法:./writeback.sh <start_id> <end_id> <batch=2000>
set -euo pipefail
SRC="recovery-tmp.internal"; DST="prod-master.internal" # SRC 是校验用的真值来源
TBL=order_item; BATCH=${3:-2000}; PROGRESS=/tmp/writeback.progress
ok=0
for start in $(seq "$1" "$BATCH" "$2"); do
end=$(( start + BATCH - 1 ))
[ "$end" -gt "$2" ] && end=$2
# 每批单独事务,失败只影响这一批
mysql -h "$DST" -e "
START TRANSACTION;
REPLACE INTO $TBL SELECT * FROM tmp_$TBL WHERE id BETWEEN $start AND $end;
COMMIT;" || { echo "FAILED at ${start}-${end}, rerun with: $0 $start $2 $BATCH"; exit 1; }
# 每批校验:原表这一段的行数与金额合计,必须与真值来源一致
truth=$(mysql -N -h "$SRC" -e "SELECT COUNT(*), COALESCE(SUM(amount),0) FROM $TBL WHERE id BETWEEN $start AND $end")
now=$(mysql -N -h "$DST" -e "SELECT COUNT(*), COALESCE(SUM(amount),0) FROM $TBL WHERE id BETWEEN $start AND $end")
[ "$truth" = "$now" ] || { echo "MISMATCH at ${start}-${end}: expect [$truth] got [$now]"; exit 1; }
echo "$start-$end ok" >> "$PROGRESS"
ok=$(( ok + 1 ))
sleep 0.2 # 给主从一点喘息时间,回写永远是 C3 级变更
done
echo "done, $ok batches"两个细节值得说:
- 用
REPLACE INTO还是UPDATE?如果恢复实例上的行是完整正确的,REPLACE更省事(它按唯一键删除再插入)。但它会改掉故障后新增的列值,且会触发DELETE语义(影响AUTO_INCREMENT、外键级联、审计字段)。如果表上有故障后新增的字段,必须用显式字段列表的UPDATE,只覆盖出错的那几列。 sleep 0.2不是客气。12 万行回写会在主库上产生 12 万行的 redo 和 binlog,从库要重放;在业务高峰回写,等于给一个正在恢复的系统再加一次变更。第 19 篇的规矩在这里照用:回写是 C3,走窗口,走灰度(先 1 万行,观察复制延迟,再继续)。
还有一条容易忽略的:回写完成不等于事故结束。 第 22 篇的结束四条件里那条"没有悬着的不可逆操作",指的就是这里——补单、对账、下游缓存刷新、搜索索引重建、数据仓库重新同步,这些都不在数据库里,但都在用那份错数据。
七、不是所有事故都要恢复:先判断哪一份是真相
恢复是最后手段,不是第一反应。数据事故先分类,四类的处置路径完全不同。
| 类型 | 表现 | 首选手段 | 备注 |
|---|---|---|---|
误 DELETE / UPDATE | 能定位到语句和时间点 | binlog 反解(或恢复 + 单表回写) | 前提:binlog_format=ROW、binlog_row_image=FULL |
误 DDL(DROP / TRUNCATE) | 表没了 | 恢复 + 单表导出导入 | MySQL 没有回收站,DROP TABLE 之后只剩备份 |
| 逻辑性脏写 | 数据"合法但错误"(金额算错、状态机跳错) | 业务口径修复脚本 | 反解会把脏写也反解回来,binlog 帮不上 |
| 覆盖且无 binlog | binlog 被 purge / 格式是 STATEMENT | 只能恢复,RPO 等于上次全量 | 这是最坏的一类,也是最该防的 |
7.1 binlog 反解:先把"要改哪几行、改成什么值"搞清楚
binlog2sql 和 my2sql 是这一类的主力。目的不是省时间,而是在动生产之前,先拿到一份可审阅的变更清单。
# 1) 用 flashback 模式生成反向 SQL(不改数据,只生成文件)
binlog2sql -h replica.internal -u binlog_reader -p'***' \
-d order_db -t order_item \
--start-file='mysql-bin.000412' \
--start-datetime='2026-11-07 15:19:00' \
--stop-datetime='2026-11-07 15:22:00' \
--flashback > /tmp/order_item_rewind.sql
# 2) 先看规模:多少行、涉及哪些主键区间
grep -c '^UPDATE' /tmp/order_item_rewind.sql
grep -oP 'WHERE id=\K[0-9]+' /tmp/order_item_rewind.sql | sort -n | head -3
grep -oP 'WHERE id=\K[0-9]+' /tmp/order_item_rewind.sql | sort -n | tail -3
# 3) 抽样核对:把反向 SQL 的一条应用到预发,对比"误操作前"的值
head -5 /tmp/order_item_rewind.sql | mysql -h staging.internal order_db三个硬前提,缺一个这套东西就是废的:
binlog_format=ROW。STATEMENT 格式下UPDATE t SET amount = amount * 1.1反解不出具体值,只能重放动作本身。binlog_row_image=FULL。MINIMAL模式下 binlog 只记被改的列,反解时缺"前值",WHERE条件不完整,可能改错行。- 账号有
REPLICATION SLAVE/REPLICATION CLIENT权限,且能连到一个有完整 binlog 的实例(通常是从库)。
7.2 时区:反解最常错的一处
--start-datetime 的解释依赖会话时区,而 binlog 里的时间戳是事件发生时刻的本地时间。跨时区的实例(主库 UTC、从库 CST、应用 JVM 又是另一个时区)上,--start-datetime='15:19:00' 可能取到的是 8 小时之前的一段 binlog。取错窗口的后果是反解出一批不该动的行。
规避方法很土但有效:先用 SELECT NOW() 在同一个连接上看服务端时间,再按那个时间填参数,并且反解完成后用第一节的 T_validate 那套校验核一遍。
7.3 一条判断规则:什么时候别费劲反解
反解的适用条件是"误操作集中在一个短时间窗、行数可枚举、前值在 binlog 里完整"。反过来的情况——跨了几个小时的连续脏写、行数上百万、涉及多张表和下游系统——反解的校验成本会超过恢复成本。这时候恢复 + 单表回写反而更可靠,因为它有一个明确的校验基准:恢复那一刻的完整状态。
判断标准很简单:能不能在 30 分钟内把反向 SQL 全量审阅一遍。 不能,就走恢复。
八、慢查询:慢的不是 Query_time
前面七节讲的是"已经坏了"。数据层的日常 80% 是另一件事:它还没坏,但一直在拖。 慢查询是这里面最常见、也最容易被做形式的一项。
8.1 慢日志里最没用的那一列
慢查询日志(slow_query_log,阈值 long_query_time)默认关心的是 Query_time。但决定优化方向的是另外三列,pt-query-digest 的 Profile 里都有:
Rows_examined/Rows_sent:扫描行数与返回行数之比。这个比值是优化收益的直接指标,比例越高越好改。Lock_time:锁等待时间。它说明的不是 SQL 写得差,而是有长事务或热点行冲突。Full_scan(pt-query-digest会标Full_scan: Yes):有没有走全表。
一个真实形状:某查询 Query_time 只有 380ms,QPS 3,看起来完全无害。但 Rows_examined 是 210 万,Rows_sent 是 20。它一天扫了 180 亿行,占了主库 40% 的 CPU。按 Query_time 排序它进不了前 20,按 Rows_examined 排序它是第一。
pt-query-digest --since=1d /var/log/mysql/slow.log > /tmp/slow.report
# 直接按扫描行数排,而不是默认的 Query_time 总耗时
pt-query-digest --order-by Rows_examined:sum --since=1d /var/log/mysql/slow.log | head -408.2 优化顺序,别乱
从收益比最高的往下走,不要一上来就分库分表:
- 看执行计划,确认
type是index/range而不是ALL - 补索引,注意最左前缀与函数包裹(
WHERE DATE(created_at) = ...会让索引失效) - 覆盖索引,把
SELECT *收窄到索引能覆盖的列(避免回表,通常能省 5~10 倍 IO) - 分页改游标(
WHERE id > last_id LIMIT n) - 读写分离,把报表类查询赶到从库
- 最后才是归档 + 分表
前四步在 90% 的场景里够用,而且都不需要改代码结构。
九、连接池:连接数是一种容量资源
第 21 篇那个案例——CPU 平均 40% 却挂了,真凶是连接数打满 max_connections——到这一篇才算讲全。因为连接数的分母在数据库侧,分子在应用侧,两边从来没人一起算过。
9.1 一个必须能背下来的不等式
Σ(每个服务的 pool_size × 该服务副本数) + 后台/运维连接 ≤ max_connections × 0.8那个 0.8 是留给运维、备份、复制、监控连接的余量。第 21 篇的案例里:订单服务 40 副本 × pool 30 = 1200,加上其他服务,总数 1600,而 max_connections 是 1000。这个不等式在扩容之前就已经不成立了,扩容只是把它暴露出来。
#!/usr/bin/env bash
# conn-budget.sh —— 算连接预算,接进第 21 篇的容量看板
# 输入:服务清单(服务名、副本数、HikariCP maximumPoolSize)
# 输出:总需求、缺口、按服务排序的 TOP 占用
set -euo pipefail
MAX_CONN=$(mysql -N -e "SELECT @@max_connections")
# services.csv 形如: service,replicas,pool_size
# order-api,40,30
awk -F, -v maxc="$MAX_CONN" '
NR>1 { need[$1] = $2 * $3; total += need[$1] }
END {
usable = maxc * 0.8
printf "max_connections=%d usable(80%%)=%.0f demand=%d\n", maxc, usable, total
if (total > usable) printf "OVER by %d —— 先降 pool 或加实例,不要加副本\n", total-usable
for (n in need) printf " %-24s %d\n", n, need[n]
}' services.csv9.2 连接池最容易踩的三个坑
坑一:maxLifetime 大于 wait_timeout。 服务端 wait_timeout 默认 28800(8 小时),但云数据库、SLB、代理层经常把它调到 60~300 秒。应用侧的连接池如果 maxLifetime 比它大,就会拿到一条服务端已经关掉、但客户端还以为活着的连接,报 Communications link failure / Connection reset。这类错误有个特征:偶发、集中在业务低峰(因为空闲连接刚好被回收)。
规矩:maxLifetime 要比链路上最短的空闲超时再小 30 秒。
坑二:connectionTimeout 大于上游超时。 应用等连接池 30 秒,而上游网关 5 秒就超时了。请求早就被上游放弃,连接池还在排队——这正是第 22 篇 20:18 那次"改超时引发重试放大"的数据库侧版本。
规矩:connectionTimeout < 上游超时 < 用户可接受等待。
坑三:把 minimumIdle 设得很小。 低峰时连接池缩到 2 条,突发流量来了要现建 30 条连接,每条要 TCP 握手 + 认证(MySQL 认证还要算上 TLS 和 caching_sha2_password 的往返)。在 RTT 2ms 的内网这不算什么,在跨可用区 1.5ms 的链路上乘以并发,就是那几百毫秒的尾部延迟。
9.3 用 eBPF 分清"打满"和"泄漏"
连接数满了,有两种完全不同的原因:流量正常但池子小(调参)和连接泄漏(改代码)。第 14 篇那套 eBPF 工具在这里正好接上。
# 1) 谁在占连接:按客户端 IP 聚合(应用侧)
ss -tnp state established '( dport = :3306 )' \
| awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -nr | head
# 2) 连接活了多久:区分"忙"和"漏"
# tcp_set_state(struct sock *sk, int state) → arg0 = sk, arg1 = state
bpftrace -e '
kprobe:tcp_set_state {
$sk = (uint64)arg0; $ns = (int64)arg1;
if ($ns == 1) { @start[$sk] = nsecs; } // TCP_ESTABLISHED
if ($ns == 7 && @start[$sk]) { // TCP_CLOSE
@dur_s = hist((nsecs - @start[$sk]) / 1000000000);
delete(@start[$sk]);
}
}'
# @dur_s 的长尾如果拖到小时级,基本就是连接泄漏(正常业务连接的存活是分钟级)
# 3) 库里侧的真相:Sleep 状态的连接占多少
mysql -e "SELECT command, COUNT(*) FROM information_schema.processlist GROUP BY command;"
# Sleep 占 90% 且 Time 分布极长 → 泄漏或不设 maxLifetime判断规则:库里 Sleep 状态的连接超过 70%,且 Time 有大量超过 10 分钟的,是泄漏;Query 状态占多数、Sleep 数量正常但总数顶到上限的,是池子不够或并发太高。
十、主从延迟:什么叫"可容忍"
"延迟小于 1 秒"不是可容忍的定义,它只是一个人人会说但没法执行的数字。可容忍的定义必须绑定读路径。
10.1 先扔掉 Seconds_Behind_Master
SBM 有三个结构性缺陷,任何一个都足以让它不能当告警指标:
- 主库没写入时它是 0。 主库空闲、从库 SQL 线程已经停了三分钟,
SBM依然显示 0——因为它算的是"最后一个已执行事件的时间戳 vs 现在",没事件就没有差。 - 并行复制下它不代表全部事务。 MySQL 8.0 的
WRITESET并行复制会有多个 worker,SBM取的是低水位,但低水位之下的 worker 可能还在补。 - 它在跳变。 大事务执行完的一瞬间,
SBM会从 300 跳到 0。
替代方案是心跳表(pt-heartbeat):主库每 interval 秒往一张表写一行时间戳,从库读出来算差值。这个数在主库空闲时也准确,因为写入是主动产生的。
# 主库:每秒写一次心跳(--update 模式,常驻)
pt-heartbeat --update -h primary -D heartbeat_db --table heartbeat \
--create-table --interval=1 --daemonize
# 从库:监控延迟(超过 1 秒输出)
pt-heartbeat -h replica.internal -D heartbeat_db --table heartbeat --monitor --master-server-id=1mysql_heartbeat_ts 不是 pt-heartbeat 自带的指标,需要一个小 exporter 或定时任务把它读成 Prometheus 指标(一行 SELECT UNIX_TIMESTAMP(ts) 就够)。对应的告警规则:
- alert: ReplicaLagHigh
expr: |
max by (instance) (
timestamp() - max by (instance) (mysql_heartbeat_ts)
) > 5
for: 2m
labels: { severity: warning }
annotations:
summary: "从库 {{ $labels.instance }} 心跳延迟 > 5s"10.2 延迟怎么定档:按读路径分
同一个延迟数字,对不同读路径的意义完全不同。所以定档的维度不是"秒数",是"这条读路径能不能忍"。
| 读路径 | 例子 | 可容忍延迟 | 超了怎么办 |
|---|---|---|---|
| 强一致读 | 下单后立即查订单状态、支付结果 | 0(必须走主) | 路由到主库,读放大由主库扛 |
| 会话内读 | 用户刚提交的表单回显 | 500ms | 路由到主,或用会话粘滞 |
| 一般业务读 | 列表页、详情页 | 2s | 从库读;超阈值降级到本地缓存 |
| 报表 / 导出 | 日结、BI 拉数 | 1h | 完全不管,走专用从库,加熔断 |
| 数据同步 | CDC / 数仓 | 分钟级 | 只监控,不干预 |
这张表的价值在于:它把"主从延迟告警"从一条全局规则,变成了每个调用方自己声明的一个约束。落地方式是在数据访问层(或代理层)声明读策略,而不是靠开发记。
10.3 大事务是延迟的头号原因,而它是可以工程化的
大事务对面复制的影响不是"慢一点":一个更新 50 万行的事务,在 ROW 格式下会产生 50 万条 binlog 事件,从库要在一个事务里执行完才能提交。这段时间里(可能 30 秒到几分钟)其它所有事务都在它后面排队。一个批处理任务,能让整个从库停摆。
所以控制手段很具体:
- 限制单事务行数:批处理按主键区间切,每批 2000~5000 行,每批独立事务
- 把这条限制放进 CI:静态扫
UPDATE/DELETE语句,没有LIMIT或WHERE里没有主键/索引列的,直接报错 - 无主键表是灾难放大器:ROW 格式下没有主键,从库重放每一行都要全表扫。这条必须进建表规范,且用脚本定期扫出来
-- 定期盘点:无主键的表(这些表的主从延迟随时会失控)
SELECT t.table_schema, t.table_name, t.table_rows
FROM information_schema.tables t
LEFT JOIN information_schema.statistics s
ON s.table_schema = t.table_schema AND s.table_name = t.table_name AND s.index_name = 'PRIMARY'
WHERE t.table_type = 'BASE TABLE' AND s.index_name IS NULL
AND t.table_schema NOT IN ('mysql','sys','performance_schema','information_schema')
ORDER BY t.table_rows DESC;十一、大表 DDL:窗口、工具,以及最危险的那 8 秒
第 19 篇把 DDL 归到了 C3 变更,并给了 expand → migrate → contract 的顺序。这一节讲执行层面的三件事:什么时候能做、用什么做、以及那个躲不开的切换瞬间。
11.1 三种执行算法,先说清楚代价
| 操作 | 8.0 支持的算法 | 是否重建表 | 元数据锁 | 典型耗时(800GB) |
|---|---|---|---|---|
ADD COLUMN(末尾) | INSTANT(8.0.12+) | 否 | 毫秒 | 秒级 |
ADD COLUMN(中间)/ DROP COLUMN | INSTANT(8.0.29+,有前提) | 否 | 毫秒 | 秒级 |
ADD INDEX | INPLACE, LOCK=NONE | 否(但要在表上排序建索引) | 无阻塞 | 数十分钟到数小时 |
MODIFY COLUMN / 改类型 | COPY(多数情况) | 是,两份表空间 | 全程无阻塞但非常重 | 数小时 |
ADD FOREIGN KEY | INPLACE(foreign_key_checks=0) | 否 | 短 | 分钟级 |
OPTIMIZE TABLE | INPLACE | 是 | 无 | 数小时,别在业务表上做 |
几条经验:加列首选 INSTANT,但要知道它的两个限制——不能加 FULLTEXT 索引相关的列,且 INSTANT 加列会让表的"行版本"增加,长期累积多版本会拖慢读。 用 ALGORITHM=INSTANT, LOCK=NONE 显式声明,让 MySQL 在不支持时报错,而不是悄悄降级成 COPY:
ALTER TABLE order_item
ADD COLUMN channel_code VARCHAR(32) NULL COMMENT '归因渠道',
ALGORITHM=INSTANT, LOCK=NONE;
-- 如果这条报 "ALGORITHM=INSTANT is not supported",说明当前操作不支持
-- 那就必须走 gh-ost,不能让它自动降级到 COPY 去锁表
-- 第 19 篇的 expand 阶段靠的就是这一句11.2 超过 50GB,用在线工具
INPLACE 的问题不是锁,是第二次写入:它要把整个表的数据复制一份,期间所有 DML 都要记到 innodb_online_alter_log_max_size 限制的日志里。并发写入量大时,会直接失败:
Creating index 'idx_created_at' required more than 'innodb_online_alter_log_max_size' bytes这时走在线工具。gh-ost 和 pt-osc 的差别不是性能,是工作方式和风险形状:
| 维度 | gh-ost | pt-online-schema-change |
|---|---|---|
| 增量同步方式 | 伪装成从库读 binlog | 在源表上建三个触发器 |
| 对主库的额外负载 | 读 binlog,几乎无锁 | 触发器增加每次 DML 的开销 |
| 能否暂停 / 限流 | ✅ 支持,运行时还能改 | 有限(--max-load / --critical-load) |
| 表的限制 | 无主键不支持、外键受限 | 有触发器的表不能用、有外键的表要用 --alter-foreign-keys-method |
| 切换时的锁 | RENAME TABLE,毫秒级,但需要拿 MDL | 同样是 RENAME,需先删触发器 |
| 适合 | 大表、持续写入、想精细控制 | 中小表、没有触发器和外键 |
gh-ost 的关键参数不是 --alter,是三个限流开关:
gh-ost \
--host=replica.internal --port=3306 \
--database=order_db --table=order_item \
--alter="ADD INDEX idx_created_at (created_at)" \
--allow-on-master \
--max-load='Threads_running=25' \
--critical-load='Threads_running=80' \
--critical-load-interval-millis=1000 \
--throttle-control-replicas='replica2.internal:3306' \
--max-lag-millis=1500 \
--chunk-size=1000 \
--switch-to-rbr --exact-rowcount \
--hooks-path=/etc/gh-ost/hooks \
--initially-drop-ghost-table --initially-drop-old-table \
--execute 2>&1 | tee /var/log/gh-ost/order_item_$(date +%Y%m%d).log--max-lag-millis=1500 是重点:DDL 期间如果从库延迟超过 1.5 秒,gh-ost 自动暂停。这等于把第 10 节的读路径可容忍延迟,接到了 DDL 的执行流程上——同一个阈值,两个用途。
11.3 最危险的不是 DDL 本身,是那 8 秒的元数据锁等待
不管用 INPLACE 还是 gh-ost,最后都有一个 RENAME TABLE 或 ALTER 需要拿表的排他元数据锁(MDL)。这个动作本身是毫秒级的。但如果此刻有一条长事务正在访问这张表,RENAME 会等它——而 RENAME 一排队,它后面所有对这张表的查询都会排在它后面排队。表在 30 秒内彻底不可用。
这条因果链是 MySQL 最经典的自伤:一个没人注意的长事务 + 一次"毫秒级"的 DDL = 一次全表不可用。
规避流程是固定的,写进 DDL 的操作手册:
#!/usr/bin/env bash
# ddl-guard.sh —— 执行任何 DDL 之前必须先跑这个
# 检查四项:长事务、大表体积、复制延迟、是否业务高峰
set -euo pipefail
TBL=${1:-order_item}
MAX_TRX_S=${2:-30}
# 1) 有没有超过 30 秒的事务(这是致命项,必须为 0)
long=$(mysql -N -e "
SELECT COUNT(*) FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > $MAX_TRX_S;")
[ "$long" -eq 0 ] || { echo "ABORT: $long long-running trx, kill them first"; exit 1; }
# 2) 表体积与预计耗时(决定是否走低峰窗口)
mysql -e "SELECT table_name, ROUND(data_length/1024/1024/1024,1) AS data_gb,
ROUND(index_length/1024/1024/1024,1) AS idx_gb
FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='$TBL';"
# 3) 复制延迟(GTID 差集,比 SBM 可靠)
mysql -e "SELECT @@gtid_executed\G" > /tmp/master_gtid.txt
mysql -N -h replica.internal -e "SELECT @@gtid_executed\G" > /tmp/replica_gtid.txt
# 差异非空 → 延迟存在,DDL 的尾延迟会被放大
# 4) 业务高峰判断(接第 21 篇的业务日历,而不是看时钟)
hour=$(date +%H)
case "$hour" in
0[0-5]) echo "OK: low-peak window" ;;
*) echo "WARN: business hours, confirm with on-call before proceeding" ;;
esac三条硬规矩:
- DDL 前必须确认长事务为 0,必要时
SELECT ... FROM innodb_trx拿trx_mysql_thread_id定向 kill,不要kill整个连接池 - 会话级
lock_wait_timeout调小(比如 5 秒),让这次RENAME快速失败重试,而不是把队列拖死 - 人在场、能中止。
gh-ost随时可以panic;ALTER一旦开始,只能等
11.4 一个把不可逆变成可逆的例子:分区表
如果那张表是按时间分区的,删历史数据就不是 DELETE(不可逆、产生海量 binlog、拖垮复制),而是:
-- 删一个分区:元数据操作,毫秒级,几乎不产生 binlog
ALTER TABLE orders DROP PARTITION p202610;
-- 更稳的做法:先交换出去,观察一周再删
ALTER TABLE orders EXCHANGE PARTITION p202610 WITH TABLE orders_archive_202610;同一个业务需求(清理历史数据),两种做法的风险差三个量级。"能不能把它变成可回退的",是数据层设计里最值得投入的一件事。 它比"恢复得快"更根本——因为最好的恢复是不需要恢复。
十二、把不可逆变成可回退:事故前的五件事
数据层 SRE 的大部分功夫不在事后,在事前。按性价比排序:
1. 危险语句拦截。 生产库不给应用账号 DROP / TRUNCATE 权限,这是最基础的。再往上一层是 SQL 审核:UPDATE / DELETE 没有 WHERE、WHERE 里没有索引列、影响行数超过阈值,一律走人工审批。第一节那个脚本就是被审过的——审核只看语法不看语义,所以"条件里少一个字段"这类问题,只有影响行数预估能挡。
# sql-guard.py 的核心判断(挂在 SQL 平台的提交前钩子)
def risk(sql, table):
if not re.search(r'\b(WHERE)\b', sql, re.I) and re.match(r'\s*(UPDATE|DELETE)', sql, re.I):
return 'C4', 'no WHERE clause'
rows = estimate_affected_rows(sql, table) # EXPLAIN + 索引统计
if rows > 50000:
return 'C4', f'estimated {rows} rows'
if rows > 5000:
return 'C3', f'estimated {rows} rows'
return 'C2', 'ok'2. 变更前留单表快照。 改一张关键表之前,先 mysqldump --single-transaction --where 把要动的行留一份。成本是几分钟,收益是"操作失误后不用恢复 800GB"。
3. 软删除 + 延迟物理删除。 不是所有删除都要软删(会污染查询和索引),但用户主动删除这类业务场景,留 7~30 天的软删窗口,等于给自己一个零成本的回收站。
4. 归档而不是删除。 历史数据移到一个归档实例,保留可查。这样"删错了"变成"改一条路由"。
5. 把数据变更纳入第 19 篇的分级。 明确到字段级别,避免每次 DDL 都靠人讨论:
| 级别 | 数据变更 |
|---|---|
| C1 | 只读查询、建临时表 |
| C2 | ADD COLUMN(可空/有默认)、加索引、加从库 |
| C3 | ADD INDEX 于大表、加唯一约束、双写开始、批量回写 |
| C4 | DROP / TRUNCATE / MODIFY COLUMN 改类型 / 删列 / 大版本升级 / 恢复演练上生产后的数据回灌 |
十三、度量:数据层的五个数
数据层的监控面板常常只有三个数:QPS、连接数、复制延迟。这三个数都是"现在好不好",没有一个回答"坏了能不能救"。真正该看的五个:
| 指标 | 定义 | 健康值 | 为什么是它 |
|---|---|---|---|
| 备份可恢复率 | 周演练成功次数 / 计划次数 | 100% | 唯一能证明"备份真实存在"的数。90% 意味着每十周有一次救不回来 |
| RTO 实测 P90 | 演练产出的 rto_actual 分布 | ≤ 承诺值 | 承诺值是估算的,这个数是实测的。两者差 3 倍说明承诺从来没对上过 |
| RPO 实测最坏值 | now - 最晚可恢复时间点 的最大值 | ≤ 承诺值 | 托管实例这一项经常滞后 40 分钟,且没人知道 |
| 复制延迟超阈值时长 | 一周内延迟 > 阈值累计分钟数 | < 30 分钟/周 | 它决定了"读路径降级"会不会频繁触发 |
| 变更引发的数据事故占比 | 数据事故中由变更(DDL/脚本/发布)引起的比例 | 趋势下降 | 第 19 篇讲变更安全,这个数是它的数据层版本 |
#!/usr/bin/env python3
# data-sre-metrics.py —— 从演练记录算前三个数,按月输出
# 记录来源:restore-drill/runs/*.yaml(每次演练产出一份)
import glob, math, yaml
runs = [yaml.safe_load(open(f)) for f in glob.glob("restore-drill/runs/*.yaml")]
ok = [r for r in runs if r["result"] == "pass"]
rto = [r["rto_actual_min"] for r in ok]
def pctl(xs, q): # 小样本直接用次序统计量,不插值
xs = sorted(xs)
return xs[max(0, math.ceil(len(xs) * q) - 1)]
print(f"备份可恢复率: {len(ok)}/{len(runs)} = {len(ok)/len(runs):.0%}")
if rto:
p90 = pctl(rto, 0.9)
print(f"RTO 实测: P50={pctl(rto, 0.5)}min P90={p90}min 最差={max(rto)}min")
print(f"承诺值: {runs[0]['rto_committed_min']}min "
f"{'✅ 达标' if p90 <= runs[0]['rto_committed_min'] else '❌ 承诺失真,重算'}")
print(f"RPO 实测最坏: {max(r['rpo_actual_min'] for r in runs)}min")
print("最高频 blockers:")
for b in sorted({b for r in runs for b in r["blockers"]}):
n = sum(1 for r in runs if b in r["blockers"])
print(f" {n} 次 · {b}")最后那一段 blockers 统计是全篇最想强调的一件事:演练的产出不是"通过",是一份按频次排序的阻塞清单。 修掉出现 3 次以上的那几条,下一次的 RTO 就会掉一半。
十四、二十五项数据层自检清单
按"备份 / 恢复 / 日常 / 防护"四组,可以直接拿去逐条对。每项都写到可验收。
备份
- 每个实例都有备份,且备份清单是自动生成的(不是靠人记)
- 备份与数据库物理隔离(不同机器 / 不同可用区),备份账号与生产账号分离且无权删备份
- 备份脚本
set -euo pipefail,成功标记为显式产物(xtrabackup_checkpoints/ 完成文件) - 每天校验备份体积与新鲜度,异常告警
- binlog 保留窗口 ≥ 承诺的 RPO × 2
- 每天校验 binlog 链连续(备份的 binlog 与实例当前的 binlog 能接上)
- 物理备份明确记录了对应的 MySQL 版本与参数(
my.cnf),恢复时版本可查
恢复
- 有一台预置的恢复实例(规格 ≥ 主库),
T_provision从 30 分钟降到 5 分钟 - 每周至少一次自动恢复演练,产出
rto_actual/rpo_actual/blockers - 恢复演练包含金丝雀查询(行数、金额聚合、字符集、对象数量)
- 单表导出/回写的脚本已就绪,按主键分批、可中断、可重入、每批校验
- 回写前会先备份"要覆盖的行的当前值"
mysqldump参数固定为--single-transaction --routines --events --triggers --default-character-set=utf8mb4 --source-data=2- 恢复流程有一份可照敲的手册,且最近一次演练是按手册走的(不是靠记忆)
日常
- 慢查询按
Rows_examined排序,而不只看Query_time - 连接预算有不等式,且在扩容前被计算(Σ pool × 副本 ≤ max_connections × 0.8)
maxLifetime< 链路上最短空闲超时 - 30s;connectionTimeout< 上游超时- 主从延迟用心跳表,不用
Seconds_Behind_Master - 读路径分级表存在,且每个调用方声明了自己的延迟容忍
- 无主键表已盘清;大事务有行数上限,且在 CI 里检查
防护
- 应用账号无
DROP/TRUNCATE权限,写操作走审核 UPDATE/DELETE影响行数预估接入审批(阈值 5000 / 50000)binlog_format=ROW且binlog_row_image=FULL(数据事故的最后一道防线)- DDL 前检查长事务、表体积、复制延迟、业务窗口,四项有脚本
- 超过 100GB 的表做过 DDL 耗时实测(预发或影子库),耗时进入变更窗口评估
十五、从零开始的四阶段路线
阶段一(第 1~2 周):先知道自己有什么
- 盘清所有实例:版本、数据量、备份方式、保留策略、binlog 保留窗口,落成一张表
- 算一遍
T_restore = D / R,把结果和 RTO 承诺并排放。两者差 5 倍以上的,先把承诺改掉,再谈优化 - 打开
binlog_format=ROW、binlog_row_image=FULL(要重启或滚动切换,排进 C3 窗口) - 部署
backup-audit.sh,先把"备份是否真的存在"变成每天一个数
这一阶段不产出任何新能力,但它是后面所有事的分母。第一节那个案例里,如果只有这一阶段的成果,"当天 02:00 备份失败"会在 02:05 被发现。
阶段二(第 3~6 周):让恢复变成一条走通过的路
- 预置一台恢复实例,把规格、账号、网络、磁盘挂载写成清单
- 做第一次完整演练:恢复最近一份全量 + 重放 binlog 到某个时间点 + 跑金丝雀查询
- 记录
rto_actual、blockers,把 blockers 按频次排序,修掉前三条 - 部署心跳表 + 延迟告警;把读路径分级表发出去,让每个调用方认领自己的容忍值
阶段二是全篇性价比最高的一段。多数团队第一次演练的 RTO 是承诺值的 3~5 倍,而修掉 blockers 之后,第二次通常能减半。
阶段三(第 2~3 月):日常不再靠直觉
- 慢查询治理:按
Rows_examined排 TOP 20,改掉扫行数最多的五条 - 连接预算接入容量看板(第 21 篇),扩容流程里加一道"连接数是否够"的检查
- DDL 流程:
ddl-guard.sh+gh-ost工具链 + C1-C4 分级表,先做 3 个真实案例 - 危险语句拦截与影响行数预估上线(可以先只做告警,不阻断)
阶段四(第 4~6 月):把它变成一个季度节奏
- 恢复演练进季度排期,且带上 IC 和 CL 走一遍(第 22 篇):数据事故一定涉及跨团队沟通和对外口径
- 分区表改造:把最大的两张日志/订单类表按时间分区,让"删历史数据"从不可逆变成
DROP PARTITION - 把
备份可恢复率和RTO 实测 P90写进 SLO 报告,成为和可用性并列的数 - 演练结果接进发布门禁:备份不可恢复率低于 100% 的实例,禁止做 C4 变更
十六、和前面几篇的关系
- 第 14 篇(eBPF 网络可观测性):那篇教怎么在内核侧看连接。本文第 9.3 节用它区分"连接池不够"和"连接泄漏"——
Sleep状态的连接时长分布,是这两个问题唯一可靠的分界线 - 第 19 篇(变更安全体系):那篇说"回滚不是撤销键",本文展开的是那个不可逆的部分。两篇的接口有三处:expand → migrate → contract 是 DDL 的顺序约束;DDL 属于 C3;"已写入的数据只能补偿不能撤销"这句话的全部工程含义在这一篇
- 第 20 篇(混沌工程与 Game Day):数据层的恢复演练是 Game Day 的变体。差别在于:系统演练验证"故障下还能不能工作",数据演练验证"坏了能不能回来"。前者测能力,后者测退路
- 第 21 篇(容量规划):
max_connections是一种容量资源,只是它的分母不在应用侧。第 21 篇的资源秒记账法在这里要加上一行"连接秒";本文第 9.1 节那个不等式,是容量模型里最容易被漏掉的一行 - 第 22 篇(事故指挥 IC):数据事故是 IC 最难拍板的一类——不可逆、要跨 DBA / 财务 / 法务、恢复时长远超技术恢复。本文的恢复演练如果要真做,第 22 篇的三角分工必须一起上,否则演练只测了技术,没测"谁有权决定恢复哪个时间点"
- 第 24 篇(AI 排障的真实边界):AI 在数据层最靠谱的落点是"从 binlog 和慢日志里生成时间线与候选变更清单",最不靠谱的是"替你决定恢复哪个时间点"——因为那是一个业务决策,赔多少钱、丢多少数据、要不要对外说,都不是模型能算的
写在最后
如果这篇只带走一句:
备份不是一份文件,是一条被验证过的路径。
文件在不在,ls 一下就知道;路径通不通,只有真的走一遍才知道。第一节那 5 小时 40 分里,有 80 分钟花在一条从来没被走过的路上——找一份能用的备份、开一台规格对的机器。这 80 分钟跟数据量无关,跟你走没走过有关。
如果只带走一个动作:
本周挑一个非核心实例,把最近一份备份恢复到一个临时实例,跑三条查询:行数、当日金额汇总、对象数量。
这件事需要一两个小时。它产出的不是一个"通过",而是三个数字:真实的恢复耗时、真实的 RPO、以及一份"原来这里会卡住"的清单。下一次真出事时,你会省下的是这三个数字乘以十。
数据层的事故有个共同特征:它不发生则已,发生一次就能把前面二十几篇攒下的所有效率一次性吃掉。可用性掉到 90% 能靠扩容救回来,数据错了一万行只能靠时间。
这也正是"没演练过的备份等于没有备份"这句话的真正含义——它不是一句恐吓,它是一条实测结论:任何没有在压力下被执行过的机制,在第一次被执行时都会失败。 第 18 篇统计过,没演练过的 Runbook 在实战中被反馈"看不懂/走不通"的比例是 41%,演练过的是 8%;第 20 篇那次 Game Day 的 Runbook 首次可用率是 25%。备份的首次恢复成功率不会比这更好——它连"看不懂"都算不上,它只是从来没人打开过。
本文是 SRE 实战系列第 23 篇,第二循环第二组第二篇。下一篇(第 24 篇)回到 AI:AI 排障的真实边界——它能替你看指标,不能替你定责。